Skip to content

ccl: hierarchical cross-node AllGather (intra-node SDMA + inter-node RDMA) - #441

Merged
inkcherry merged 230 commits into
mainfrom
inkcherry/sdma-hier-allgather
Jul 30, 2026
Merged

ccl: hierarchical cross-node AllGather (intra-node SDMA + inter-node RDMA)#441
inkcherry merged 230 commits into
mainfrom
inkcherry/sdma-hier-allgather

Conversation

@inkcherry

@inkcherry inkcherry commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds mori.ccl.HierAllGather — a hierarchical cross-node AllGather primitive, drop-in compatible with torch.distributed.all_gather_into_tensor. Intra-node traffic rides the SDMA copy engines (XGMI) and inter-node traffic goes over RDMA (NIC), keeping the collective's data movement off the compute units. Single-node degenerates to the pure intra-node SDMA path; the same object handles multi-node with no signature change.

Technical details

  • Two-leg hierarchical exchange: intra-node SDMA sub-group gather (XGMI) + inter-node RDMA ring over node-blocks.
  • Inter-node leg selectable: host-proxy (CPU-posted RDMA) or device IBGDA (GPU-posted); intra-node leg selectable SDMA or NCCL.
  • Bit-exact vs torch.distributed.all_gather_into_tensor (zero tolerance, {bf16, fp16, fp32, int32}).
  • Includes an example FSDP2 backend (MoriAllGather) that wires the primitive in via FSDPModule.set_custom_all_gather.

Test plan / results

Validated on two platformsMI300X + Mellanox mlx5 and MI355X + AINIC (ionic) RoCEv2.

Full numbers, figures, raw logs, and one-key reproduce scripts are under examples/fsdp_sdma/bench/ (results/mi300x_mlx5/, results/mi355x_ainic/; see examples/fsdp_sdma/README.md).

…RDMA)

Add mori.ccl.HierAllGather: an all_gather_into_tensor-compatible collective that
keeps intra-node traffic on the SDMA copy engines (XGMI) and moves inter-node
traffic over RDMA. A fused ring||local-gather kernel runs the inter-node RDMA
ring concurrently with the ring-independent local node-block SDMA gather in one
grid (stream-ordered, direct-to-output, no staging copy).

Bit-exact vs torch.distributed.all_gather_into_tensor for {bf16,fp16,fp32,int32}.
On 2 nodes x 4 GPUs (MI355X), fp32: standalone bandwidth >= RCCL for sizes
>=8MB (1.19-1.35x); under a concurrent GEMM the SDMA path overlaps with compute
and is 16-20% faster than RCCL at 128-512MB (copy engines vs CU contention).

Includes tests (test_hier_allgather*), size-sweep + gemm-overlap benches, a
plot script, and the measured result charts/CSVs under benchmarks/.
…ignature

HierAllGather now auto-detects the node-local rank count (LOCAL_WORLD_SIZE, else
hostname grouping, else npes) so callers use the same constructor/call signature
as the flat AllgatherSdma with no new required argument. ranks_per_node is now
optional and keyword-only; added transit_buffer_size for signature parity.
Single node still degenerates to the pure intra-node SDMA path.
Resolve conflicts with the upstream param-contiguous SDMA allgather:
- oneshot_sdma_kernel.hpp: keep both the hierarchical sub-group/broadcast SDMA
  kernels and the upstream param-contiguous kernel (additive).
- symmetric_memory.cpp: keep deviceHandles_d indexing by global pe (the array is
  worldSize-sized and all SDMA kernels index by global pe); adopt the upstream
  non-fatal GPU-metadata teardown.
Add HierAllGather.all_gather(tensor_list, tensor) matching
torch.distributed.all_gather (list output), built on the same hierarchical
intra-node SDMA / inter-node RDMA path as the contiguous all_gather_into_tensor.
Bit-exact vs torch across {bf16,fp16,fp32,int32}; adds test_hier_allgather_list.
…A + inter RDMA)

Adds a single drop-in FSDP2 AllGather backend, MoriAllGather, used identically
for single-node and cross-node runs via the stock
FSDPModule.set_custom_all_gather API. It routes intra-node traffic over SDMA
copy engines (XGMI) and, when the process group spans multiple nodes, inter-node
traffic over RDMA — the same object handles both, so user code is unchanged
between one node and many. (MoriHierAllGather kept as a backward-compat alias.)

Includes the Qwen-7B FSDP2 step benchmark, a 2-node driver, and the chart
script. No mori source change needed; the HierAllGather primitive already exists
and handles the single-node case as pure intra-node SDMA.
@inkcherry
inkcherry force-pushed the inkcherry/sdma-hier-allgather branch from c8d4eca to 4188668 Compare July 3, 2026 12:20
inkcherry added 22 commits July 3, 2026 13:21
… to beat RCCL xnode)

Motivation: cross-node FSDP2 lost to RCCL only because HierAllGather had no
param-contiguous output, forcing the backend to copy-out rank-major->param on
every gather. HierAllGather.enqueue_param_contiguous now PUSHES the gathered
result straight into FSDP's [param][rank] output via the existing per-slot
direct gather (no new C++ kernel): per (node-block m, param s) it scatters with
dst_block_offset=O_s*W+m*G*E_s, dst_slot_stride=E_s so rank r lands at O_s*W+r*E_s.
Adapter sets supports_param_contiguous_output + builds dtype-elem splits.
2-node bit-exact vs torch all_gather_into_tensor (bf16/fp16/fp32/int32, 3 reps).
…l N*P launch overhead to beat RCCL xnode)

Motivation: zero-copy killed FSDP copy-out but the per-(node-block,param) loop
issued N_nodes*N_params SubGroup launches per all-gather; that launch overhead
regressed 2-node FSDP to 106 TFLOPS (< RCCL 128). New fused
OneShotAllGatherSdmaSubGroupParamContiguousKernel loops all blocks+splits inside
one launch. 2-node standalone bit-exact PASS bf16/fp16/fp32/int32.
…the inter-node RDMA ring (recover ring||gather overlap zero-copy lost to serial path; lever to beat RCCL xnode)
…fault under FSDP; standalone bit-exact)

The ring||local-scatter overlap zero-copy path is bit-exact in the 2-node
standalone test but triggers an HSA memory-exception under FSDP's repeated-call/
buffer-reuse pattern. Keep the proven non-overlap fused scatter as the default
zero-copy path; enable overlap with MORI_HIER_PC_OVERLAP=1 to iterate the fault.
…ng path); catches non-bit-exact scatter (num_blocks=1/W=G) behind the +17.6% HSDP result
… register-once regression test (isolates num_blocks=1 scatter bug; num_blocks=N cross-node path re-confirmed bit-exact)
…ntiguous kernel (align to proven flat kernel); add receiver-slot diagnostic to intra test

Fused subgroup param-contiguous scatter proven buggy via single-node repro
(world=4 G=4 num_nodes=1): concurrent-warp multi-put scrambles/drops sender
data despite correct offsets. threadfence_system (SDMA async, unaffected) only
shifted the pattern. Per-put gather_kernel_direct loop predecessor was bit-exact.
…per-op cross-node barrier is the FSDP gap (SDMA 102.9->112.1, beats RCCL 105.6 when removed); justifies generation-counter barrier-free ring
…item()/.tolist() drained the pipeline every call, destroying AG<->backward overlap under FSDP); cache u32 split tensors
…intra barriers) — decisive A/B falsifies barrier-skew: all-barrier removal gives 0% recovery (111.98->111.79), gap is not per-op barrier serialization
… was an undersized-output IPC-registration artifact, NOT a concurrent-put race

Root cause (proven via fast single-node nproc=4 repro + a size sweep): the intra
param-contiguous direct scatter writes to peerPtrs[remotePe]+dstBaseOffset assuming
the registered peer pointer == the output buffer base. When the standalone test's
output was small (bf16, ~16MB) torch SUB-allocated it inside a larger pool segment,
so ShmemSymmetricRegister/hipIpcGetMemHandle resolved the peer pointer to the SEGMENT
base (not the buffer) and the scatter SILENTLY CORRUPTED (wrong slots). float32
(2x larger) crossed the own-segment threshold and passed, which made the failure look
dtype/num_blocks-specific. Enlarging the output to its own segment makes num_blocks=1
bit-exact for bf16/fp16/fp32/int32 (12 reps). The kernel was never buggy; a race is not
dtype-size-deterministic. Bumps _PARAM_SPLITS to force own-segment; adds probe_nb.py.
…contiguous scatters (shared per-groupPos flag slot)

The overlapped param-contiguous zero-copy path (MORI_HIER_PC_OVERLAP=1, the historic
best 113.9 TFLOPS win candidate) ran the side-stream LOCAL-block scatter concurrently
with the main-stream REMOTE-block scatters. Both call gather_kernel_direct_param_contiguous,
which shares ONE per-groupPos flag slot + seq token on the intra handle; concurrent use
let a receiver observe the other scatter's flag bump -> premature completion / spin-deadlock
under FSDP (the Turn 4/14 hang). Move main.wait_stream(side) BEFORE the remote loop so the
two scatter phases are serialized. The key overlap (side local scatter || inter-node RDMA
ring) is preserved: the ring finish is enqueued on main before the wait, so it still runs
concurrent with the side scatter; only the ring-dependent remote scatters wait. Kernel
correctness itself is now proven bit-exact (see prior commit).
…ut on the side stream so the caching allocator does not recycle FSDP buffers mid-scatter
…hurn per AG call (each change = a cross-node ShmemSymmetric register/deregister collective that cannot overlap; candidate for the in-FSDP per-AG inflation vs RCCL)
…oss-nondeterminism is an async completion-capture bug — forced stream.synchronize() at op return gives bit-exact loss==native (11.0992556 x2), while on-device ShmemBarrierOnStream does not drain the local SDMA copy-engine DMA before FSDP consumes the output
…ns on the peer-completion flag with a SYSTEM-scope acquire (AtomicLoadSeqCstSystem) + __threadfence_system instead of an AGENT-scope relaxed load; the flag+data are produced by a REMOTE peer GPU (different HSA agent) so AGENT-scope gives no cross-agent happens-before and the copy-OUT could consume not-yet-visible data under rapid FSDP reuse (host sync masked it)
… elementwise copy-out) to test the copy-engine<->CU coherence root cause for FSDP loss-drift; both CU-write-output and CU-read-transit variants leave loss in the same drift band => the stale bytes are in out_ itself (SDMA receiver drain), not the copy-out engine
…iguous zero-copy — reproduces the FSDP AG->backward hazard (AG on comm stream, consumer waits on a recorded event on the main stream, per-rep varying inputs + layout-weighted consumer + compute pressure); proves the zero-copy direct path is bit-exact AND deterministic under overlap at ~2M-elem splits => the FSDP loss drift is NOT in the AG kernel completion at this regime (look to copy-out path / small-layer routing next)
… deployed FSDP perf path) + small/single-split size profiles — cross-node world=8 shows BOTH the copy-out and zero-copy AllGather paths are bit-exact AND deterministic under the FSDP AG->consumer cross-stream overlap at every size band (0/40 wrong); NaN-safe verdict. => the ~0.15% FSDP loss drift is NOT in HierAllGather completion ordering; redirect to the downstream FSDP path (reduce-scatter / bf16 accum) or an internal side-stream event-join not captured by the caller event
… mode; mixing copy-OUT __call__ and zero-copy enqueue_param_contiguous on ONE handle contaminated shared intra flag/seq + output-registration state (copy-OUT registers transit out_, zero-copy registers the USER output), spuriously yielding a stable-NaN for [zerocopy bf16 nsplit=5] right after copy-OUT ops. The pure-mode bit-exact test PASSES bf16 at that exact config, and FSDP uses one mode per run, so per-mode handles are the faithful harness. Now 12/12 configs 0/40-wrong => both AG output paths bit-exact + deterministic under FSDP AG->consumer cross-stream overlap at every size band; the ~0.15%% FSDP loss drift is NOT in the AG
…ther truth ref

Large profile (68M elems/rank; gathered int32 ~2.18GB, crosses 2^31 bytes) probes
u32 byte-offset overflow at the embed+lm_head band -> 2-node bit-exact PASS
(bf16/fp32/int32), ruling out size-dependent AG corruption. Overlap UT now checks
the async output against an INDEPENDENT all_gather reference (not the self-golden),
so a stable-but-wrong copy-out drain would be caught -> still 0 wrong at all bands,
falsifying the in-kernel drain-race theory for the FSDP loss drift.
… receiver flag wait

The InterNodeRing receiver spun on the completion flag with core::AtomicLoadRelaxed
and no acquire fence, unlike the intra SDMA gather (AtomicLoadSeqCstSystem +
__threadfence_system). The flag is bumped by a REMOTE peer's RDMA AMO and the chunk
it guards is landed by that peer's RDMA put -- both cross-agent writes; a relaxed load
establishes no happens-before, so the received data need not be coherently visible to
the forward-put / copy-out. Harden to a system-scope acquire matching the proven intra
pattern. NOTE: in-situ AG-output probe (MORI_FSDP_AG_VERIFY) shows this alone does NOT
close the FSDP loss race (184/384 AG calls still read stale vs RCCL, 0/384 under host
sync) -- the residual race is in the copy-out drain (ring-buffer->output visibility), the
next target.
@inkcherry
inkcherry marked this pull request as ready for review July 23, 2026 09:24
af86cb1 over-reached and deleted pre-existing all2all/allreduce/
allreduce_async/rccl_allgather tests unrelated to this feature. Bring them back.
Restore anvil.cpp / shmem_ibgda_kernels.hpp / init.cpp to main except the
functional bits (anvil HIP-ordinal->HSA-agent BDF fix), and drop stray comment/
whitespace rewords in symmetric_memory.cpp + mlx5.cpp. Keeps the PR diff to
feature-relevant changes only.
@inkcherry
inkcherry force-pushed the inkcherry/sdma-hier-allgather branch from df8ea60 to b90260e Compare July 23, 2026 10:07
Both were set True in __init__ with no ctor arg and no env override, so
every else arm behind them was dead. collective.py still defaults
stream_ring=False, so the call sites now pass True literally.
Ctor-only opt-ins, both mutually exclusive with slice_inter which defaults
True, so the ctor raised ValueError for any combination that would reach
them. Nothing in the repo passes either kwarg.
442 lines with a three-way path switch in one method. Pure extraction into
_call_pipe_band / _call_sliced / _call_sliced_fused_remote /
_call_sliced_fused_local / _call_nonsliced; _call_impl is down to the
dispatch. Stream, event and barrier order is unchanged.
Last references keeping those two branches reachable. The three
fuse_barrier scenarios stay -- that knob is live on the shipped path.
Stored and never read; copy-paste from _HostProxyDeferredWork where it is
live. Dropping the ref is safe because bench.py clamps prefetch depth to 1,
so wait() always precedes the next __call__ -- _get_collective can rebuild
the collective on a cap change, so the Work object's reference is not
guaranteed to be a non-last one.
Both files already had their own copyright line before the license hook
prepended the canonical block.
The direct path needs num_nodes >= 2 over RDMA. On a single node the file
printed SKIP, asserted nothing and still reported PASSED.
leader_only / gather_in_place / out_in_place are gone from the product
code, so the stub no longer has to define them.
@inkcherry
inkcherry requested a review from jhchouuu July 28, 2026 03:25

@jhchouuu jhchouuu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For shmem section, I think is acceptable.

Comment on lines +43 to +44
int intraNodePe = pe % 8;
int intraNodePe = pe; // index by GLOBAL pe: deviceHandles_d/signalPtrs/expectSignalsPtr are
// host-populated per global pe (symmetric_memory.cpp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you use a global pe id, may be rename intraNodePe to peSlot?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, renamed

Comment on lines +353 to +364
// (not globalRank % 8, which faults under sliced HIP_VISIBLE_DEVICES).
int localDevId = 0;
for (int j = 0; j < LocalRank(); j++)
if (peerInfos[j].sameHost) localDevId++;
for (int i = 0; i < WorldSize(); i++) {
if (!peerCaps[i].canSDMA) continue;
if (i != LocalRank()) anvil::EnablePeerAccess(LocalRank() % 8, i % 8);
anvil::anvil.connect(LocalRank() % 8, i % 8, sdmaNumChannels);
// Peer within-node device id: count of same-host peers before it.
int peerDevId = 0;
for (int j = 0; j < i; j++)
if (peerInfos[j].sameHost) peerDevId++;
if (i != LocalRank()) anvil::EnablePeerAccess(localDevId, peerDevId);
anvil::anvil.connect(localDevId, peerDevId, sdmaNumChannels);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same logic as L177? Perhaps the logic can be unified.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unified into a SameHostPeersBefore() helper.

Comment on lines +169 to +173
// BDF of the HIP device, parsed from its "domain:bus:device.function" string.
std::string busId = getBusId(hipDeviceId);
unsigned domain = 0, bus = 0, dev = 0, func = 0;
std::sscanf(busId.c_str(), "%x:%x:%x.%x", &domain, &bus, &dev, &func);
uint32_t hipBdf = ((bus & 0xFF) << 8) | ((dev & 0x1F) << 3) | (func & 0x7);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has the domain not been used? On multiple PCIe segments, two GPUs on the same machine may share the same 16-bit BDFID → resulting in a mismatch with the correct agent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HSA BDFID has no domain, so I match on the 16-bit BDF only when it's unique, else fall back to identity. No change on single-segment; avoids the wrong pick on multi-segment.


#if defined(__HIPCC__) || defined(__CUDACC__)

// SDMA_PKT_COPY_LINEAR DW2 (PARAMETER_UNION) stays 0: peak D2D/XGMI copy BW.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean this comment?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleand up

@jhchouuu

Copy link
Copy Markdown
Collaborator

and agent review FYR:

Review — hierarchical cross-node AllGather

Blocking

1. deepPipe is computed independently in Python and C++, and they disagree → OOB write + read of uninitialised memory

python/mori/ccl/hier_allgather.py:864-865 applies a 32 MiB coherence gate (sub-chunk ≥ 32 MiB
→ depth 1 → a single flag slot) and the comment states it "Mirrors the C++ HierDeepPipe gate".
include/mori/collective/ccl_kernel_args.hpp:43 (HierDeepPipe()) has no such gate and
returns 2 by default. With chunkBytes ≥ 64 MiB the ring publishes both chunkReadyFlags[0]
and [1] (inter_node/kernels/all_gather.hpp:289, :398) into a one-element tensor: an 8-byte
write past the allocation, and slot 1 is never cleared by flags.zero_(), so the reassembly
worker reads uninitialised memory as "landed" and SDMA-pushes ring bytes the NIC has not written
yet — silent wrong data.

Please make the depth single-sourced: either pass the Python-computed depth down, or replicate
the gate in HierDeepPipe().

2. Reassembly uses deepPipe unconditionally, but the ring only engages deep-pipe under six preconditions

src/collective/kernels/ccl_kernels.hip:371 does
const int pipe = args.deepPipe > 1 ? args.deepPipe : rb; with no check of whether the ring
actually took the deep-pipe path. inter_node/kernels/all_gather.hpp:117 requires
deepPipe > 1 && peerIsRdma && prevIsRdma && maxRounds == 1 && !multiBlock && chunkReadyFlags && useWarps >= 1;
if any of those fails the ring publishes only chunkReadyFlags[bid]. A single-node / loopback
run (!peerIsRdma) or a P2P neighbour reaches this today, and partition f ≥ 1 then waits on a
flag nobody ever sets.

Related: the ring clamps P by reqPmax (all_gather.hpp:126) but the reassembly side does
not, so the two also diverge when chunkBytes < deepPipe MiB. Suggest deriving both from one
place (engage-decision + clamped P).

3. Bounded spin fallbacks turn a hang into silent corruption

src/collective/kernels/ccl_kernels.hip:327 and :437:
if (++spin > 100000000) break; // bounded fallback. After the break the code proceeds to push
/ consume the data anyway; at :327 that means pushing ring bytes over XGMI before the NIC wrote
them. The deep-pipe paths in the same file use __builtin_trap() — please do the same here.
Failing loudly is much better than producing wrong results.

4. The sender-side fence exists at only one of five drain → AMO sites

include/mori/collective/allgather/oneshot_sdma_kernel.hpp:201-204 inserts
__threadfence_system() between SubGroupSdmaDrainPe and the completion-flag AMO, with a
comment explaining that otherwise "the receiver can observe the flag ahead of the data".
Lines 89, 372, 439, 513 have the identical drain → AMO sequence with no fence in between.
(The fences at :118 / :231 belong to the receiver-side tail and serve a different purpose.)

Either the fence at :201 is unnecessary and should go, or these four sites have a
flag-beats-data race. Please make it consistent.


Should fix

5. waitForSignal can give up silently, and polls at the wrong scope
include/mori/core/transport/sdma/anvil_device.hpp:102-116

It returns false after MAX_RETRIES (2^30), but SdmaQueitThread and SubGroupSdmaDrainPe
ignore the return value and go on to fence and set the completion flag. Also the poll is
__ATOMIC_RELAXED at AGENT scope on a value written by a separate DMA agent; SYSTEM scope is
what's warranted here.

6. Ring flag array can overflow with DEEP_PIPE=auto
include/mori/collective/inter_node/inter_node_ring_class.hpp:121

Flags are sized numBlocks_ * npes_. Deep-pipe requires !multiBlock (so numBlocks == 1) and
writes flagBase + p for p ∈ [0, P) with P up to 16. Any configuration where P > npes
overflows. Sizing by max(npes_, kMaxDeepPipe) would close this.

7. Importing mori.ccl rewrites a process-global env var and prints to stdout
python/mori/ccl/hier_allgather.py:61, called at import time on :70

_clamp_sdma_channels() overwrites MORI_SDMA_NUM_CHANNELS. That variable is read by the core
SDMA transport (anvil.hpp:161, context.cpp:254), i.e. by subsystems that have nothing to do
with mori.ccl — so merely importing the package silently changes their behaviour. The cap
MORI_SDMA_CH_HW_MAX = 8 (:49) is also hardcoded as an MI300X value rather than probed.

Since #501 already clamps to kMaxSdmaChannelsPerPair = 8 on the C++ side, I'd drop this block
entirely after the rebase.

8. Constructor mutates os.environpython/mori/ccl/hier_allgather.py:364

os.environ["MORI_HIER_DEEP_PIPE_QUIET"] = "0" is a process-wide side effect whose value depends
on the first handle's ranks_per_node; handles constructed later in the same process inherit
it. An instance attribute would be equivalent and order-independent. (Commit d0a389a5 claims
this class of mutation was removed — this one survived.)

9. A host-proxy error leaves peers hung in the barrier
python/mori/ccl/host_proxy_ag.py:446-452

On wait_all failure the rank raises, but the other ranks are blocked in
dist.barrier(self._pair_barrier) and will wait forever. Consider signalling the failure to the
pair before raising, or bringing the barrier into the error path. _timeout_ms = 60000 (:101)
is also hardcoded — worth making configurable.

- index the SDMA handle arrays by a clearly-named global pe slot
- de-duplicate the within-node device-id computation into one helper
- match a HIP device to its HSA agent only on a unique BDF, keeping the
  identity fallback when the low 16-bit BDF is ambiguous
Resolve the SDMA-channel-cap constant duplication in favor of upstream #501's
kMaxSdmaChannelsPerPair (drop the local kSdmaMaxNumChannels alias).

@jhchouuu jhchouuu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM
add some CI test if possible?

intranode (MI355/MI325): add the CPU offset spec test and the single-node
bit-exact test (8 GPUs split into simulated nodes). single-node runs with
MORI_HIER_CUDA_GRAPH=0 -- the graph-replay fallback isn't bit-exact-safe on
every arch yet, so exercise the eager path.

internode: world=16 cross-node bit-exact smoke over real RDMA, both legs
(device IBGDA + hostproxy), via tools/run_internode_ccl_ag.sh.
--sizes-mb '8 64' lost its quotes when node2's argv went through
ssh "${NODE2_CMD[*]}" (a second word-split), so '64' hit the wrapper as a
bare arg -> 'Unknown option: 64', node2 died, node1 hung to timeout (exit 124).
use comma-separated 8,64 and split it back inside the wrapper.
RCCL's cross-node RoCE bring-up was failing on the internode runners
(MI355/AINIC: ibv_modify_qp timeout, GID index 0 N/A; MI325/BNXT: QP local
access violation) -- and that happened in the RCCL reference collective, before
mori's own AllGather ran, so the smoke test never actually exercised mori.

compute the expected output locally instead (rank-major concat of each rank's
deterministic input) and drop device_id= from init_process_group so NCCL stays
lazy and never brings up RoCE QPs. now the only cross-node path is mori's.
verified bit-exact on 2-node MI300X+mlx5 with no NCCL_IB_* set.
@inkcherry

Copy link
Copy Markdown
Contributor Author

thanks @jhchouuu , added to ci and passed, verified E2E again locally

@inkcherry
inkcherry merged commit 8a655e9 into main Jul 30, 2026
14 checks passed
@QizhouZhang97
QizhouZhang97 deleted the inkcherry/sdma-hier-allgather branch July 31, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants